Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 229c67e377499d3a81535597882388380f1a9b3f


Parents : 389a2f4
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-03T14:46:07-05:00

feat(release): add Bunny Storage integration for uploading release assets and implement tag resolution in CI workflow

Changes

2 files changed, 151 insertions(+), 0 deletions(-)


Diff

diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index a122ca14..740a8f0e 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -1,6 +1,8 @@
# Single tagged-release pipeline: Linux release assets, Windows + macOS Electron
# builds, Flatpak, Android APKs (dev/master track tags via android-apk-tag.yml), SLSA
# provenance (generic generator: Linux, desktop, optional Android+Flatpak), optional cosign bundles, and one draft GitHub release.
+# Optional: same draft ``upload/`` tree is mirrored to bunny.net Edge Storage for master/dev
+# tags when ``BUNNY_STORAGE_ACCESS_KEY`` is set (see draft job).
# One workflow run per tag keeps the release graph immutable.
#
# Pinned first-party actions (bump tag and SHA together when upgrading):
@@ -575,6 +577,33 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
+ with:
+ fetch-depth: 0
+
+ - name: Resolve tag release track
+ id: release_track
+ run: |
+ set -euo pipefail
+ git fetch --no-tags origin dev master
+ sha="$(git rev-parse HEAD)"
+ on_m=false
+ on_d=false
+ while IFS= read -r line; do
+ ref="$(printf '%s' "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
+ case "$ref" in
+ origin/master) on_m=true ;;
+ origin/dev) on_d=true ;;
+ esac
+ done < <(git branch -r --contains "${sha}")
+ if [[ "${on_m}" == true ]]; then
+ track=master
+ elif [[ "${on_d}" == true ]]; then
+ track=dev
+ else
+ track=none
+ fi
+ echo "track=${track}" >> "${GITHUB_OUTPUT}"
+ echo "Resolved tag ${GITHUB_REF_NAME} -> track=${track}"
- name: Download Linux release assets
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
@@ -664,3 +693,19 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: bash scripts/ci/github-draft-release-upload-assets.sh upload
+
+ - name: Upload release assets to Bunny Storage
+ if: ${{ steps.release_track.outputs.track == 'master' || steps.release_track.outputs.track == 'dev' }}
+ env:
+ BUNNY_STORAGE_ACCESS_KEY: ${{ secrets.BUNNY_STORAGE_ACCESS_KEY }}
+ BUNNY_STORAGE_BASE_URL: ${{ vars.BUNNY_STORAGE_BASE_URL }}
+ BUNNY_RELEASE_TRACK: ${{ steps.release_track.outputs.track }}
+ run: |
+ set -euo pipefail
+ if [[ -z "${BUNNY_STORAGE_ACCESS_KEY:-}" ]]; then
+ echo "::notice::Skipping Bunny Storage (set repository secret BUNNY_STORAGE_ACCESS_KEY to enable)."
+ exit 0
+ fi
+ export BUNNY_STORAGE_BASE_URL="${BUNNY_STORAGE_BASE_URL:-https://la.storage.bunnycdn.com/meshchatx}"
+ export BUNNY_STORAGE_OBJECT_PREFIX="${BUNNY_RELEASE_TRACK}/${GITHUB_REF_NAME}"
+ python3 scripts/ci/github-upload-bunny-storage-release-assets.py upload

diff --git a/scripts/ci/github-upload-bunny-storage-release-assets.py b/scripts/ci/github-upload-bunny-storage-release-assets.py
new file mode 100644
index 00000000..8e03bece
--- /dev/null
+++ b/scripts/ci/github-upload-bunny-storage-release-assets.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""Upload a directory tree to bunny.net Edge Storage (HTTP PUT per object)."""
+
+from __future__ import annotations
+
+import hashlib
+import mimetypes
+import os
+import sys
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+from urllib.parse import quote
+
+
+def encode_object_rel(rel: str) -> str:
+ return "/".join(quote(part, safe="") for part in rel.split("/"))
+
+
+def mime_for(path: Path) -> str:
+ if path.suffix.lower() == ".wasm":
+ return "application/wasm"
+ guessed, _enc = mimetypes.guess_type(path.name)
+ return guessed or "application/octet-stream"
+
+
+def put_file(
+ url: str,
+ body: bytes,
+ access_key: str,
+ content_type: str,
+ max_attempts: int = 4,
+) -> None:
+ checksum = hashlib.sha256(body).hexdigest().upper()
+ timeout = 600 if len(body) > 50_000_000 else 120
+ for attempt in range(1, max_attempts + 1):
+ req = urllib.request.Request(
+ url,
+ data=body,
+ method="PUT",
+ headers={
+ "AccessKey": access_key,
+ "Content-Type": content_type,
+ "Checksum": checksum,
+ },
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ code = resp.getcode()
+ except urllib.error.HTTPError as e:
+ code = e.code
+ err_body = e.read(500)
+ if code in (200, 201):
+ return
+ if 500 <= code < 600 and attempt < max_attempts:
+ time.sleep(0.5 * (2 ** (attempt - 1)))
+ continue
+ raise SystemExit(
+ f"HTTP {code} for {url}: {err_body!r}",
+ ) from e
+ except (urllib.error.URLError, TimeoutError) as e:
+ if attempt < max_attempts:
+ time.sleep(0.5 * (2 ** (attempt - 1)))
+ continue
+ raise SystemExit(f"request failed for {url}: {e}") from e
+ else:
+ if code in (200, 201):
+ return
+ raise SystemExit(f"unexpected status {code} for {url}")
+
+
+def main() -> None:
+ base = os.environ.get("BUNNY_STORAGE_BASE_URL", "").rstrip("/")
+ key = os.environ.get("BUNNY_STORAGE_ACCESS_KEY", "")
+ prefix = os.environ.get("BUNNY_STORAGE_OBJECT_PREFIX", "").strip("/")
+ if not base or not key:
+ print(
+ "BUNNY_STORAGE_BASE_URL and BUNNY_STORAGE_ACCESS_KEY must be set",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+ root = Path(sys.argv[1]).resolve()
+ if not root.is_dir():
+ print(f"not a directory: {root}", file=sys.stderr)
+ sys.exit(1)
+
+ files = sorted(p for p in root.rglob("*") if p.is_file())
+ if not files:
+ print(f"no files under {root}", file=sys.stderr)
+ sys.exit(1)
+
+ for path in files:
+ rel = path.relative_to(root).as_posix()
+ if prefix:
+ object_rel = f"{prefix}/{rel}"
+ else:
+ object_rel = rel
+ url = f"{base}/{encode_object_rel(object_rel)}"
+ body = path.read_bytes()
+ put_file(url, body, key, mime_for(path))
+ print(url)
+
+
+if __name__ == "__main__":
+ main()


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────